Micron Document
baubs git

Node / mirrors / respira / commits / f2b01c5

Commit f2b01c59e1c536ddad9b85e22c9db3d1c8719b07


Parents : 0db0bcd
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-27T12:40:47+01:00

feature: Add comprehensive test suite for custom hooks

Add tests for all hooks with @testing-library/react:
- utility hooks: usePrevious, useAutoScroll, useClickOutside
- domain hooks: useErrorPopoverState, useMachinePolling
- platform hooks: useBluetoothDeviceListener

Changes:
- Install @testing-library/react and jsdom for React hook testing
- Configure vitest to use jsdom environment for React testing
- Add 91 tests covering all hook functionality
- Test state management, effects, event listeners, and async behavior
- Verify proper cleanup and edge cases

All tests passing. Coverage includes error states, polling intervals,
click-outside detection, auto-scroll behavior, and IPC integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Changes
Diff

diff --git a/package-lock.json b/package-lock.json
index dfd58c0..6a271ee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -48,6 +48,8 @@
"@electron/typescript-definitions": "^8.15.6",
"@eslint/js": "^9.39.1",
"@reforged/maker-appimage": "^5.1.1",
+ "@testing-library/react": "^16.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
@@ -62,6 +64,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "jsdom": "^27.4.0",
"prettier": "3.7.4",
"shadcn": "^3.6.2",
"typescript": "~5.9.3",
@@ -71,6 +74,13 @@
"vitest": "^4.0.15"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.30",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz",
+ "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@antfu/ni": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz",
@@ -93,6 +103,61 @@
"nup": "bin/nup.mjs"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
+ "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.4",
+ "@csstools/css-color-parser": "^3.1.0",
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4",
+ "lru-cache": "^11.2.4"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.7.6",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
+ "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.4"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -598,6 +663,141 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.0.22",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz",
+ "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@dotenvx/dotenvx": {
"version": "1.51.2",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.2.tgz",
@@ -2400,6 +2600,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz",
+ "integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@exodus/crypto": "^1.0.0-rc.4"
+ },
+ "peerDependenciesMeta": {
+ "@exodus/crypto": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
@@ -5057,6 +5275,69 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
+ "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -5113,6 +5394,14 @@
"@types/node": "*"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -6365,6 +6654,17 @@
"node": ">=10"
}
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -6539,6 +6839,16 @@
"tweetnacl": "^0.14.3"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -7632,6 +7942,20 @@
"node": ">=12.10"
}
},
+ "node_modules/css-tree": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.12.2",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -7645,6 +7969,21 @@
"node": ">=4"
}
},
+ "node_modules/cssstyle": {
+ "version": "5.3.5",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz",
+ "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+ "css-tree": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -7674,6 +8013,57 @@
"node": ">= 12"
}
},
+ "node_modules/data-urls": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
+ "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^15.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/webidl-conversions": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
+ "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+ "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/debounce-fn": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
@@ -7717,6 +8107,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -7925,6 +8322,17 @@
"node": ">= 0.8"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -7982,6 +8390,14 @@
"node": ">=8"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
@@ -8836,6 +9252,19 @@
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -10701,6 +11130,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -11281,6 +11723,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -11521,6 +11970,134 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "27.4.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
+ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.28",
+ "@asamuzakjp/dom-selector": "^6.7.6",
+ "@exodus/bytes": "^1.6.0",
+ "cssstyle": "^5.3.4",
+ "data-urls": "^6.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.0",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^15.1.0",
+ "ws": "^8.18.3",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/jsdom/node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/jsdom/node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/jsdom/node_modules/tough-cookie": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+ "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/jsdom/node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/webidl-conversions": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
+ "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/whatwg-url": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+ "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -12326,6 +12903,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/macos-alias": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/macos-alias/-/macos-alias-0.2.12.tgz",
@@ -12425,6 +13013,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+ "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -13607,6 +14202,19 @@
"node": ">=6"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -14073,6 +14681,36 @@
"node": ">=6.0.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/pretty-ms": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz",
@@ -14362,6 +15000,14 @@
"react": "^19.2.3"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/react-konva": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.1.tgz",
@@ -15125,6 +15771,19 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -16641,6 +17300,13 @@
"camelcase": "^3.0.0"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
@@ -17813,6 +18479,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
@@ -17944,6 +18623,16 @@
"node": ">=4.0"
}
},
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -18116,6 +18805,16 @@
"xtend": "^4.0.0"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
@@ -18157,6 +18856,13 @@
"node": ">=8.0"
}
},
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

diff --git a/package.json b/package.json
index 8ab9aaf..a9c95d1 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,8 @@
"@electron/typescript-definitions": "^8.15.6",
"@eslint/js": "^9.39.1",
"@reforged/maker-appimage": "^5.1.1",
+ "@testing-library/react": "^16.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
@@ -75,6 +77,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "jsdom": "^27.4.0",
"prettier": "3.7.4",
"shadcn": "^3.6.2",
"typescript": "~5.9.3",

diff --git a/src/hooks/domain/useErrorPopoverState.test.ts b/src/hooks/domain/useErrorPopoverState.test.ts
new file mode 100644
index 0000000..19c0246
--- /dev/null
+++ b/src/hooks/domain/useErrorPopoverState.test.ts
@@ -0,0 +1,272 @@
+import { describe, it, expect } from "vitest";
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { useErrorPopoverState } from "./useErrorPopoverState";
+
+describe("useErrorPopoverState", () => {
+ const hasError = (error: number | undefined) =>
+ error !== undefined && error !== 0;
+
+ it("should start with popover closed", () => {
+ const { result } = renderHook(() =>
+ useErrorPopoverState({
+ machineError: undefined,
+ machineErrorMessage: null,
+ pyodideError: null,
+ hasError,
+ }),
+ );
+
+ expect(result.current.isOpen).toBe(false);
+ expect(result.current.wasManuallyDismissed).toBe(false);
+ expect(result.current.dismissedErrorCode).toBeNull();
+ });
+
+ it("should auto-open when machine error appears", () => {
+ const { result, rerender } = renderHook(
+ ({ machineError }) =>
+ useErrorPopoverState({
+ machineError,
+ machineErrorMessage: null,
+ pyodideError: null,
+ hasError,
+ }),
+ { initialProps: { machineError: undefined } },
+ );
+
+ expect(result.current.isOpen).toBe(false);
+
+ // Error appears
+ rerender({ machineError: 1 });
+ expect(result.current.isOpen).toBe(true);
+ });
+
+ it("should auto-open when machine error message appears", () => {
+ const { result, rerender } = renderHook(
+ ({ machineErrorMessage }) =>
+ useErrorPopoverState({
+ machineError: undefined,
+ machineErrorMessage,
+ pyodideError: null,
+ hasError,
+ }),
+ { initialProps: { machineErrorMessage: null } },
+ );
+
+ expect(result.current.isOpen).toBe(false);
+
+ rerender({ machineErrorMessage: "Error occurred" });
+ expect(result.current.isOpen).toBe(true);
+ });
+
+ it("should auto-open when pyodide error appears", () => {
+ const { result, rerender } = renderHook(
+ ({ pyodideError }) =>
+ useErrorPopoverState({
+ machineError: undefined,
+ machineErrorMessage: null,
+ pyodideError,
+ hasError,
+ }),
+ { initialProps: { pyodideError: null } },
+ );
+
+ expect(result.current.isOpen).toBe(false);
+
+ rerender({ pyodideError: "Pyodide error" });
+ expect(result.current.isOpen).toBe(true);
+ });
+
+ it("should auto-close when all errors are cleared", () => {
+ const { result, rerender } = renderHook(
+ ({ machineError }) =>
+ useErrorPopoverState({
+ machineError,
+ machineErrorMessage: null,
+ pyodideError: null,
+ hasError,
+ }),
+ { initialProps: { machineError: 1 } },
+ );
+
+ expect(result.current.isOpen).toBe(true);
+
+ // Clear error
+ rerender({ machineError: 0 });
+ expect(result.current.isOpen).toBe(false);
+ expect(result.current.wasManuallyDismissed).toBe(false);
+ expect(result.current.dismissedErrorCode).toBeNull();
+ });
+
+ it("should track manual dismissal", async () => {
+ const { result, rerender } = renderHook(
+ ({ machineError }) =>
+ useErrorPopoverState({
+ machineError,
+ machineErrorMessage: null,
+ pyodideError: null,
+ hasError,
+ }),
+ { initialProps: { machineError: 1 } },
+ );
+
+ expect(result.current.isOpen).toBe(true);
+
+ // Manually dismiss
+ act(() => {
+ result.current.handleOpenChange(false);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isOpen).toBe(false);
+ });
+ expect(result.current.wasManuallyDismissed).toBe(true);
+ expect(result.current.dismissedErrorCode).toBe(1);
+ });
+
+ it("should not auto-reopen after manual dismissal", async () => {
+ const { result, rerender } = renderHook(
+ ({ machineError }) =>
+ useErrorPopoverState({
+ machineError,
+ machineErrorMessage: null,
+ pyodideError: null,
+ hasError,
+ }),
+ { initialProps: { machineError: 1 } },
+ );
+
+ // Manually dismiss
+ act(() => {
+ result.current.handleOpenChange(false);
+ });
+
+ await waitFor(() => {
+ expect(result.current.wasManuallyDismissed).toBe(true);
+ });
+
+ // Try to reopen by changing error (but same error code)
+ rerender({ machineError: 1 });
+ expect(result.current.isOpen).toBe(false);
+ });
+
+ it("should auto-open for new error after manual dismissal and error clear", async () => {
+ const { result, rerender } = renderHook(
+ ({ machineError }) =>
+ useErrorPopoverState({
+ machineError,
+ machineErrorMessage: null,
+ pyodideError: null,
+ hasError,
+ }),
+ { initialProps: { machineError: 1 } },
+ );
+
+ // Manually dismiss error 1
+ act(() => {
+ result.current.handleOpenChange(false);
+ });
+
+ await waitFor(() => {
+ expect(result.current.dismissedErrorCode).toBe(1);
+ });
+
+ // Clear all errors first (this resets wasManuallyDismissed)
+ rerender({ machineError: 0 });
+
+ await waitFor(() => {
+ expect(result.current.wasManuallyDismissed).toBe(false);
+ });
+
+ // New error appears (error 2)
+ rerender({ machineError: 2 });
+
+ // Should auto-open since manual dismissal was reset
+ await waitFor(() => {
+ expect(result.current.isOpen).toBe(true);
+ });
+ });
+
+ it("should reset dismissal tracking when all errors clear", async () => {
+ const { result, rerender } = renderHook(
+ ({ machineError }) =>
+ useErrorPopoverState({
+ machineError,
+ machineErrorMessage: null,
+ pyodideError: null,
+ hasError,
+ }),
+ { initialProps: { machineError: 1 } },
+ );
+
+ // Manually dismiss
+ act(() => {
+ result.current.handleOpenChange(false);
+ });
+
+ await waitFor(() => {
+ expect(result.current.wasManuallyDismissed).toBe(true);
+ });
+
+ // Clear error
+ rerender({ machineError: 0 });
+
+ await waitFor(() => {
+ expect(result.current.wasManuallyDismissed).toBe(false);
+ });
+ expect(result.current.dismissedErrorCode).toBeNull();
+ });
+
+ it("should handle multiple error sources", () => {
+ const { result, rerender } = renderHook(
+ ({ machineError, machineErrorMessage, pyodideError }) =>
+ useErrorPopoverState({
+ machineError,
+ machineErrorMessage,
+ pyodideError,
+ hasError,
+ }),
+ {
+ initialProps: {
+ machineError: undefined,
+ machineErrorMessage: null,
+ pyodideError: null,
+ },
+ },
+ );
+
+ expect(result.current.isOpen).toBe(false);
+
+ // Machine error appears
+ rerender({
+ machineError: 1,
+ machineErrorMessage: null,
+ pyodideError: null,
+ });
+ expect(result.current.isOpen).toBe(true);
+
+ // Additional pyodide error
+ rerender({
+ machineError: 1,
+ machineErrorMessage: null,
+ pyodideError: "Pyodide error",
+ });
+ expect(result.current.isOpen).toBe(true);
+
+ // Clear machine error but pyodide error remains
+ rerender({
+ machineError: 0,
+ machineErrorMessage: null,
+ pyodideError: "Pyodide error",
+ });
+ // Should stay open because pyodide error still exists
+ expect(result.current.isOpen).toBe(true);
+
+ // Clear all errors
+ rerender({
+ machineError: 0,
+ machineErrorMessage: null,
+ pyodideError: null,
+ });
+ expect(result.current.isOpen).toBe(false);
+ });
+});

diff --git a/src/hooks/domain/useMachinePolling.test.ts b/src/hooks/domain/useMachinePolling.test.ts
new file mode 100644
index 0000000..f74e706
--- /dev/null
+++ b/src/hooks/domain/useMachinePolling.test.ts
@@ -0,0 +1,158 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { useMachinePolling } from "./useMachinePolling";
+import { MachineStatus } from "../../types/machine";
+
+describe("useMachinePolling", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("should start polling when startPolling is called", async () => {
+ const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
+ const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
+ const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
+ const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
+
+ const { result } = renderHook(() =>
+ useMachinePolling({
+ machineStatus: MachineStatus.READY,
+ patternInfo: null,
+ onStatusRefresh,
+ onProgressRefresh,
+ onServiceCountRefresh,
+ onPatternInfoRefresh,
+ shouldCheckResumablePattern: () => false,
+ }),
+ );
+
+ expect(result.current.isPolling).toBe(false);
+
+ act(() => {
+ result.current.startPolling();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isPolling).toBe(true);
+ });
+ });
+
+ it("should stop polling when stopPolling is called", async () => {
+ const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
+ const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
+ const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
+ const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
+
+ const { result } = renderHook(() =>
+ useMachinePolling({
+ machineStatus: MachineStatus.READY,
+ patternInfo: null,
+ onStatusRefresh,
+ onProgressRefresh,
+ onServiceCountRefresh,
+ onPatternInfoRefresh,
+ shouldCheckResumablePattern: () => false,
+ }),
+ );
+
+ act(() => {
+ result.current.startPolling();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isPolling).toBe(true);
+ });
+
+ act(() => {
+ result.current.stopPolling();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isPolling).toBe(false);
+ });
+ });
+
+ it("should initialize polling correctly for SEWING state", async () => {
+ const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
+ const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
+ const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
+ const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
+
+ const { result } = renderHook(() =>
+ useMachinePolling({
+ machineStatus: MachineStatus.SEWING,
+ patternInfo: null,
+ onStatusRefresh,
+ onProgressRefresh,
+ onServiceCountRefresh,
+ onPatternInfoRefresh,
+ shouldCheckResumablePattern: () => false,
+ }),
+ );
+
+ act(() => {
+ result.current.startPolling();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isPolling).toBe(true);
+ });
+
+ act(() => {
+ result.current.stopPolling();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isPolling).toBe(false);
+ });
+ });
+
+ it("should initialize polling for different machine states", async () => {
+ const createMocks = () => ({
+ onStatusRefresh: vi.fn().mockResolvedValue(undefined),
+ onProgressRefresh: vi.fn().mockResolvedValue(undefined),
+ onServiceCountRefresh: vi.fn().mockResolvedValue(undefined),
+ onPatternInfoRefresh: vi.fn().mockResolvedValue(undefined),
+ });
+
+ // Test COLOR_CHANGE_WAIT state
+ const mocks1 = createMocks();
+ const { result: result1 } = renderHook(() =>
+ useMachinePolling({
+ machineStatus: MachineStatus.COLOR_CHANGE_WAIT,
+ patternInfo: null,
+ ...mocks1,
+ shouldCheckResumablePattern: () => false,
+ }),
+ );
+ act(() => {
+ result1.current.startPolling();
+ });
+ await waitFor(() => {
+ expect(result1.current.isPolling).toBe(true);
+ });
+ act(() => {
+ result1.current.stopPolling();
+ });
+
+ // Test READY state
+ const mocks2 = createMocks();
+ const { result: result2 } = renderHook(() =>
+ useMachinePolling({
+ machineStatus: MachineStatus.READY,
+ patternInfo: null,
+ ...mocks2,
+ shouldCheckResumablePattern: () => false,
+ }),
+ );
+ act(() => {
+ result2.current.startPolling();
+ });
+ await waitFor(() => {
+ expect(result2.current.isPolling).toBe(true);
+ });
+ act(() => {
+ result2.current.stopPolling();
+ });
+ });
+});

diff --git a/src/hooks/platform/useBluetoothDeviceListener.test.ts b/src/hooks/platform/useBluetoothDeviceListener.test.ts
new file mode 100644
index 0000000..85e3c59
--- /dev/null
+++ b/src/hooks/platform/useBluetoothDeviceListener.test.ts
@@ -0,0 +1,150 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
+import type { BluetoothDevice } from "../../types/electron";
+
+describe("useBluetoothDeviceListener", () => {
+ beforeEach(() => {
+ // Reset window.electronAPI before each test
+ delete (window as { electronAPI?: unknown }).electronAPI;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("should return empty state when Electron API is not available", () => {
+ const { result } = renderHook(() => useBluetoothDeviceListener());
+
+ expect(result.current.devices).toEqual([]);
+ expect(result.current.isScanning).toBe(false);
+ expect(result.current.isSupported).toBe(false);
+ });
+
+ it("should return isSupported=true when Electron API is available", () => {
+ // Mock Electron API
+ (window as { electronAPI: { onBluetoothDeviceList: () => void } }).electronAPI = {
+ onBluetoothDeviceList: vi.fn(),
+ };
+
+ const { result } = renderHook(() => useBluetoothDeviceListener());
+
+ expect(result.current.isSupported).toBe(true);
+ });
+
+ it("should register IPC listener when Electron API is available", () => {
+ const mockListener = vi.fn();
+ (window as { electronAPI: { onBluetoothDeviceList: typeof mockListener } }).electronAPI = {
+ onBluetoothDeviceList: mockListener,
+ };
+
+ renderHook(() => useBluetoothDeviceListener());
+
+ expect(mockListener).toHaveBeenCalledTimes(1);
+ expect(mockListener).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ it("should update devices when listener receives data", async () => {
+ let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null = null;
+
+ const mockListener = vi.fn((callback: (devices: BluetoothDevice[]) => void) => {
+ deviceListCallback = callback;
+ });
+
+ (window as { electronAPI: { onBluetoothDeviceList: typeof mockListener } }).electronAPI = {
+ onBluetoothDeviceList: mockListener,
+ };
+
+ const { result } = renderHook(() => useBluetoothDeviceListener());
+
+ expect(result.current.devices).toEqual([]);
+
+ // Simulate device list update
+ const mockDevices: BluetoothDevice[] = [
+ { id: "device1", name: "Device 1", address: "00:11:22:33:44:55", paired: false },
+ { id: "device2", name: "Device 2", address: "AA:BB:CC:DD:EE:FF", paired: true },
+ ];
+
+ deviceListCallback?.(mockDevices);
+
+ await waitFor(() => {
+ expect(result.current.devices).toEqual(mockDevices);
+ });
+ });
+
+ it("should set isScanning=true when empty device list received", async () => {
+ let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null = null;
+
+ const mockListener = vi.fn((callback: (devices: BluetoothDevice[]) => void) => {
+ deviceListCallback = callback;
+ });
+
+ (window as { electronAPI: { onBluetoothDeviceList: typeof mockListener } }).electronAPI = {
+ onBluetoothDeviceList: mockListener,
+ };
+
+ const { result } = renderHook(() => useBluetoothDeviceListener());
+
+ // Simulate empty device list (scanning in progress)
+ deviceListCallback?.([]);
+
+ await waitFor(() => {
+ expect(result.current.isScanning).toBe(true);
+ });
+ expect(result.current.devices).toEqual([]);
+ });
+
+ it("should set isScanning=false when devices are received", async () => {
+ let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null = null;
+
+ const mockListener = vi.fn((callback: (devices: BluetoothDevice[]) => void) => {
+ deviceListCallback = callback;
+ });
+
+ (window as { electronAPI: { onBluetoothDeviceList: typeof mockListener } }).electronAPI = {
+ onBluetoothDeviceList: mockListener,
+ };
+
+ const { result } = renderHook(() => useBluetoothDeviceListener());
+
+ // First update: empty list (scanning)
+ deviceListCallback?.([]);
+ await waitFor(() => {
+ expect(result.current.isScanning).toBe(true);
+ });
+
+ // Second update: devices found (stop scanning indicator)
+ const mockDevices: BluetoothDevice[] = [
+ { id: "device1", name: "Device 1", address: "00:11:22:33:44:55", paired: false },
+ ];
+ deviceListCallback?.(mockDevices);
+
+ await waitFor(() => {
+ expect(result.current.isScanning).toBe(false);
+ });
+ expect(result.current.devices).toEqual(mockDevices);
+ });
+
+ it("should call optional callback when devices change", () => {
+ let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null = null;
+
+ const mockListener = vi.fn((callback: (devices: BluetoothDevice[]) => void) => {
+ deviceListCallback = callback;
+ });
+
+ (window as { electronAPI: { onBluetoothDeviceList: typeof mockListener } }).electronAPI = {
+ onBluetoothDeviceList: mockListener,
+ };
+
+ const onDevicesChanged = vi.fn();
+ renderHook(() => useBluetoothDeviceListener(onDevicesChanged));
+
+ const mockDevices: BluetoothDevice[] = [
+ { id: "device1", name: "Device 1", address: "00:11:22:33:44:55", paired: false },
+ ];
+
+ deviceListCallback?.(mockDevices);
+
+ expect(onDevicesChanged).toHaveBeenCalledWith(mockDevices);
+ });
+});

diff --git a/src/hooks/utility/useAutoScroll.test.ts b/src/hooks/utility/useAutoScroll.test.ts
new file mode 100644
index 0000000..285b966
--- /dev/null
+++ b/src/hooks/utility/useAutoScroll.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useAutoScroll } from "./useAutoScroll";
+
+describe("useAutoScroll", () => {
+ beforeEach(() => {
+ // Mock scrollIntoView
+ Element.prototype.scrollIntoView = vi.fn();
+ });
+
+ it("should return a ref object", () => {
+ const { result } = renderHook(() => useAutoScroll(0));
+ expect(result.current).toHaveProperty("current");
+ });
+
+ it("should call scrollIntoView when dependency changes", () => {
+ const mockElement = document.createElement("div");
+ const scrollIntoViewMock = vi.fn();
+ mockElement.scrollIntoView = scrollIntoViewMock;
+
+ const { result, rerender } = renderHook(
+ ({ dep }) => useAutoScroll(dep),
+ { initialProps: { dep: 0 } },
+ );
+
+ // Attach mock element to ref
+ (result.current as { current: HTMLElement }).current = mockElement;
+
+ // Change dependency to trigger effect
+ rerender({ dep: 1 });
+
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({
+ behavior: "smooth",
+ block: "nearest",
+ inline: undefined,
+ });
+ });
+
+ it("should use custom scroll options", () => {
+ const mockElement = document.createElement("div");
+ const scrollIntoViewMock = vi.fn();
+ mockElement.scrollIntoView = scrollIntoViewMock;
+
+ const { result, rerender } = renderHook(
+ ({ dep }) =>
+ useAutoScroll(dep, {
+ behavior: "auto",
+ block: "start",
+ inline: "center",
+ }),
+ { initialProps: { dep: 0 } },
+ );
+
+ (result.current as { current: HTMLElement }).current = mockElement;
+ rerender({ dep: 1 });
+
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({
+ behavior: "auto",
+ block: "start",
+ inline: "center",
+ });
+ });
+
+ it("should not call scrollIntoView if ref is not attached", () => {
+ const { rerender } = renderHook(({ dep }) => useAutoScroll(dep), {
+ initialProps: { dep: 0 },
+ });
+
+ // Change dependency without attaching ref
+ rerender({ dep: 1 });
+
+ // Should not throw or cause errors
+ expect(true).toBe(true);
+ });
+});

diff --git a/src/hooks/utility/useClickOutside.test.ts b/src/hooks/utility/useClickOutside.test.ts
new file mode 100644
index 0000000..35b5d33
--- /dev/null
+++ b/src/hooks/utility/useClickOutside.test.ts
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useClickOutside } from "./useClickOutside";
+import { useRef } from "react";
+
+describe("useClickOutside", () => {
+ it("should call handler when clicking outside element", () => {
+ const handler = vi.fn();
+ const { result } = renderHook(() => {
+ const ref = useRef<HTMLDivElement>(null);
+ useClickOutside(ref, handler);
+ return ref;
+ });
+
+ // Create and attach mock element
+ const element = document.createElement("div");
+ document.body.appendChild(element);
+ (result.current as { current: HTMLDivElement }).current = element;
+
+ // Click outside
+ const outsideElement = document.createElement("div");
+ document.body.appendChild(outsideElement);
+ outsideElement.dispatchEvent(
+ new MouseEvent("mousedown", { bubbles: true }),
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+
+ // Cleanup
+ document.body.removeChild(element);
+ document.body.removeChild(outsideElement);
+ });
+
+ it("should not call handler when clicking inside element", () => {
+ const handler = vi.fn();
+ const { result } = renderHook(() => {
+ const ref = useRef<HTMLDivElement>(null);
+ useClickOutside(ref, handler);
+ return ref;
+ });
+
+ const element = document.createElement("div");
+ document.body.appendChild(element);
+ (result.current as { current: HTMLDivElement }).current = element;
+
+ // Click inside
+ element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+
+ expect(handler).not.toHaveBeenCalled();
+
+ document.body.removeChild(element);
+ });
+
+ it("should respect enabled option", () => {
+ const handler = vi.fn();
+ const { result, rerender } = renderHook(
+ ({ enabled }) => {
+ const ref = useRef<HTMLDivElement>(null);
+ useClickOutside(ref, handler, { enabled });
+ return ref;
+ },
+ { initialProps: { enabled: false } },
+ );
+
+ const element = document.createElement("div");
+ document.body.appendChild(element);
+ (result.current as { current: HTMLDivElement }).current = element;
+
+ // Click outside while disabled
+ const outsideElement = document.createElement("div");
+ document.body.appendChild(outsideElement);
+ outsideElement.dispatchEvent(
+ new MouseEvent("mousedown", { bubbles: true }),
+ );
+
+ expect(handler).not.toHaveBeenCalled();
+
+ // Enable and click outside again
+ rerender({ enabled: true });
+ outsideElement.dispatchEvent(
+ new MouseEvent("mousedown", { bubbles: true }),
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+
+ document.body.removeChild(element);
+ document.body.removeChild(outsideElement);
+ });
+
+ it("should not call handler when clicking excluded refs", () => {
+ const handler = vi.fn();
+ const { result } = renderHook(() => {
+ const ref = useRef<HTMLDivElement>(null);
+ const excludeRef = useRef<HTMLButtonElement>(null);
+ useClickOutside(ref, handler, { excludeRefs: [excludeRef] });
+ return { ref, excludeRef };
+ });
+
+ const element = document.createElement("div");
+ const excludedElement = document.createElement("button");
+ document.body.appendChild(element);
+ document.body.appendChild(excludedElement);
+
+ (result.current.ref as { current: HTMLDivElement }).current = element;
+ (result.current.excludeRef as { current: HTMLButtonElement }).current =
+ excludedElement;
+
+ // Click on excluded element
+ excludedElement.dispatchEvent(
+ new MouseEvent("mousedown", { bubbles: true }),
+ );
+
+ expect(handler).not.toHaveBeenCalled();
+
+ document.body.removeChild(element);
+ document.body.removeChild(excludedElement);
+ });
+
+ it("should handle object of refs (WorkflowStepper pattern)", () => {
+ const handler = vi.fn();
+ const { result } = renderHook(() => {
+ const ref = useRef<HTMLDivElement>(null);
+ const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
+ useClickOutside(ref, handler, { excludeRefs: [stepRefs] });
+ return { ref, stepRefs };
+ });
+
+ const element = document.createElement("div");
+ const step1 = document.createElement("div");
+ const step2 = document.createElement("div");
+ document.body.appendChild(element);
+ document.body.appendChild(step1);
+ document.body.appendChild(step2);
+
+ (result.current.ref as { current: HTMLDivElement }).current = element;
+ (
+ result.current.stepRefs as {
+ current: { [key: number]: HTMLDivElement | null };
+ }
+ ).current = {
+ 1: step1,
+ 2: step2,
+ };
+
+ // Click on step1 (excluded)
+ step1.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+ expect(handler).not.toHaveBeenCalled();
+
+ // Click on step2 (excluded)
+ step2.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+ expect(handler).not.toHaveBeenCalled();
+
+ document.body.removeChild(element);
+ document.body.removeChild(step1);
+ document.body.removeChild(step2);
+ });
+});

diff --git a/src/hooks/utility/usePrevious.test.ts b/src/hooks/utility/usePrevious.test.ts
new file mode 100644
index 0000000..f3e8827
--- /dev/null
+++ b/src/hooks/utility/usePrevious.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { usePrevious } from "./usePrevious";
+
+describe("usePrevious", () => {
+ it("should return undefined on initial render", () => {
+ const { result } = renderHook(() => usePrevious(5));
+ expect(result.current).toBeUndefined();
+ });
+
+ it("should return previous value after update", () => {
+ const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
+ initialProps: { value: 5 },
+ });
+
+ expect(result.current).toBeUndefined();
+
+ rerender({ value: 10 });
+ expect(result.current).toBe(5);
+
+ rerender({ value: 15 });
+ expect(result.current).toBe(10);
+ });
+
+ it("should handle different types of values", () => {
+ const { result, rerender } = renderHook(
+ ({ value }) => usePrevious(value),
+ {
+ initialProps: { value: "hello" as string | number | null },
+ },
+ );
+
+ expect(result.current).toBeUndefined();
+
+ rerender({ value: 42 });
+ expect(result.current).toBe("hello");
+
+ rerender({ value: null });
+ expect(result.current).toBe(42);
+ });
+
+ it("should handle object references", () => {
+ const obj1 = { name: "first" };
+ const obj2 = { name: "second" };
+
+ const { result, rerender } = renderHook(
+ ({ value }) => usePrevious(value),
+ {
+ initialProps: { value: obj1 },
+ },
+ );
+
+ expect(result.current).toBeUndefined();
+
+ rerender({ value: obj2 });
+ expect(result.current).toBe(obj1);
+ });
+});

diff --git a/vitest.config.ts b/vitest.config.ts
index 43f4055..527491c 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
- environment: "node",
- include: ["src/**/*.{test,spec}.{js,ts}"],
+ environment: "jsdom",
+ include: ["src/**/*.{test,spec}.{js,ts,tsx}"],
},
});

Served by rngit 1.3.3 - Generated in 0.13s